Skip to content

feat: the scope fence, pasta + nftables per step (step 5, PR 6a) - #22

Merged
mkhellat merged 2 commits into
mainfrom
feat/scope-fence
Nov 12, 2026
Merged

mkhellat merged 2 commits into
mainfrom
feat/scope-fence

Conversation

@mkhellat

Copy link
Copy Markdown
Owner

Phase 10, step 5 plan, PR 6a: the scope fence. The scope guard reads a step's targets from its command line; the fence makes the network itself hold to them. A tool that reaches further than its command line says (a redirect, a script, a bug, a plan that slipped past a parser) meets a closed door. The exploitation tier (T2) comes separately as 6b, behind this fence.

What the live prototype found (all on this machine with passt 2026_10_02)

  • pasta's defaults reach the host. A test step connected to a 127.0.0.1-only listener, and to the host through the gateway address. The fence runs pasta with --no-map-gw -t none -u none -T none -U none. With those options alone (no rules at all), the host's loopback, its gateway address and its LAN address are all unreachable: a second, independent layer.
  • Rule order matters: excluded networks must be dropped before in-scope ones are accepted.
  • setpriv can't be used: after it, bwrap can't write its uid map. Instead, bwrap runs the step with --cap-drop ALL in a child user namespace: no effective or bounding capabilities, and no-new-privileges set. nft flush ruleset is denied. There are no raw sockets either; keeping them would be unsafe, because packet sockets bypass the rules.
  • No DNS in the fence. DNS forwarded through the host's resolver would be an exfiltration channel. The step's names are resolved just before it runs into its own /etc/hosts, and resolv.conf is empty. Verified: the step's own name resolves and connects, other names fail, and dig @1.1.1.1 is blocked.

Commits

  1. 29cc2f7 Core mechanism (codebud.isolation):
    • Fence, nft_ruleset, and the pasta → nft → bwrap command line;
    • add_network_fence(): a refusal or a crashing hook stops the step;
    • a step that must be fenced is refused when the fence can't be built: no bwrap, pasta or nft, or privacy trusted;
    • pasta, nft and sh are pinned by codebud integrity pin and checked before each fenced step.
  2. ac10eb0 Policy (codebud_sec/fence.py), as you decided: names are resolved per step; scanning is fenced by default, and fence_scanning = false gives raw scans back with the guard as their only check.
    • Lookups (dig, host, whois) are not fenced.
    • nmap needs --unprivileged. SYN, UDP and OS scans and masscan are refused with that reason when the plan is judged, not only when the step runs.
    • The engagement module's stale "warn, don't block" docstring is corrected.

Verification

  • make check: 1778 passed. make distcheck passes; make info builds without warnings; ruff is clean.
  • A live test in tests/test_fence.py runs where bwrap, pasta and nft work; it was run here.
  • A live end-to-end run with the layer and the real sandbox: in-scope nmap --unprivileged and curl run fenced; nmap without --unprivileged is refused with the reason.

Phase 10, step 5 plan, PR 6 (scope fence).  An optional layer registers
a function (add_network_fence) that says, for a network step, which
addresses it may reach; the step then runs in a network namespace of
its own whose only way out is pasta, with nftables letting through only
those addresses (excluded ranges dropped first).  The mechanism is in
the core, the policy in the layer.

From the live prototype: pasta's defaults reach the machine's own
loopback and map the gateway address to it, so it runs with
--no-map-gw -t none -u none -T none -U none.  nft loads the rules in
pasta's namespace, and bwrap then starts the step with --cap-drop ALL
in a user namespace of its own: it cannot change the rules and has no
raw sockets (their packets would pass the rules by).  setpriv was not
used: after it bwrap cannot write its uid map.  There is no DNS in the
fence -- lookups through the host's resolver would carry data to
whoever serves a name -- so the step's names are resolved before it and
written into an /etc/hosts of its own, and resolv.conf is empty.

A step that must be fenced is refused when the fence cannot be built
(no bwrap, pasta or nft; privacy trusted), or when the fence function
refuses it or fails.  pasta, nft and sh are pinned by codebud integrity
pin when the security layer is installed, and checked before each
fenced step like the isolation tool.

* src/codebud/isolation.py (FENCE_PROGRAMS, Fence, nft_ruleset)
(fence_available, add_network_fence, remove_network_fence, fenced)
(prepare_with_programs): New.
(wrap): Take files to bind and no_capabilities.
(prepare): Use prepare_with_programs.
* src/codebud/sandbox.py (Sandbox.run_command): Pin the programs
prepare_with_programs added.
* src/codebud/cli/integrity.py (_programs): Pin the fence's programs.
* tests/test_fence.py: New file (live where the tools work).
* tests/test_integrity_steps.py: Stub prepare_with_programs.
* docs/chapters/configuration.texi (Isolated Steps): The network fence.
* docs/chapters/architecture.texi, docs/chapters/development.texi:
Mention it.
Phase 10, step 5 plan, PR 6.  The scope guard reads a step's targets
from its command line; the fence now makes the network itself hold to
them, so a tool that reaches further than its command line says -- a
redirect, a script, a bug, a plan that slipped past a parser -- meets a
closed door.  The guard registers it with the core's fence when the
engagement opens.

Decided with the operator (2026-11-12): a fenced step may reach exactly
its targets -- addresses and networks as written, names resolved just
before the step (the client's DNS decides; a shared CDN address is
reachable while it runs) into the step's own /etc/hosts -- and never an
excluded network.  Scanning is fenced by default; fence_scanning = false
in the engagement gives raw scans back, judged by the guard alone.
Lookups (dig, host, whois) need DNS or a registry and are not fenced.

Behind the fence there are no raw sockets: nmap needs --unprivileged,
and SYN, UDP and OS scans and masscan are refused with that reason --
when the plan is judged, not only when the step runs.  The profile's
prompt says so.  The engagement module's docstring still described the
old warn-only scope; it now says what D14i changed.

* src/codebud_sec/fence.py: New file.
* src/codebud_sec/engagement.py (_BOOL_KEYS, Engagement.fence_scanning):
New.
(load): Read it.
* src/codebud_sec/scope.py (ScopeGuard): Own a ScopeFence; register it
on activate, remove it on deactivate; report its problems.
* src/codebud_sec/data/profiles/security-pentest.toml (prompt): nmap
--unprivileged -n; no masscan behind the fence.
* src/Makefile.am: Install fence.py.
* tests/test_scope_fence.py: New file.
* tests/test_scope.py: The guard alone (fence_scanning = false).
* tests/test_scope_web.py, tests/test_security_pentest.py: Reset the
registered fences; nmap --unprivileged; masscan and SYN scans refused.
* docs/chapters/configuration.texi (Profiles): The fence for scanning.
* docs/chapters/architecture.texi, docs/chapters/development.texi:
Mention them.
@mkhellat

Copy link
Copy Markdown
Owner Author

Review summary: correct, no defects found

One full sweep over ac10eb0: conditions → scenarios → each checked against the code (and, for the fence, live) → De Morgan.

Conditions. A network step is fenced ⇔ a registered fence returns a Fence, which for an engagement means a non-passive tool ∧ fence_scanning. It is refused ⇔ the fence refuses (a raw scan, an unresolvable name, unreadable targets) ∨ the fence crashes ∨ (fenced ∧ (no bwrap ∨ no pasta or nft ∨ privacy trusted)). A fenced step reaches address a ⇔ a ∉ deny ∧ a ∈ allow (deny is checked first), or a is the step's own loopback.

The invariant behind it: a fenced step's packets reach only addresses in allow \ deny, and it can't change that.

Verified, live on this machine:

  • In-scope reaches; out of scope times out (IPv4 and IPv6); excluded networks win over allowed ones (rule order tested).
  • Nothing of the host is reachable: not 127.0.0.1, not through the gateway address, not the LAN address. A listener on the host's loopback is unreachable (the live test).
  • nft flush ruleset → Operation not permitted. The step's capabilities are empty (effective and bounding sets), no-new-privileges is set, and there are no raw sockets.
  • No DNS: only the step's own names resolve; dig @1.1.1.1 is blocked.
  • IPv6 was a suspicion here: the output policy also drops neighbour discovery. Tested: an allowed IPv6 address connects (pasta answers neighbour discovery itself) and another one doesn't.
  • The programs the fence adds (pasta, sh, nft, bwrap) are pinned at their positions, and the sandbox checks them before each fenced step.
  • A refusing or crashing fence refuses the step; privacy trusted doesn't skip it.
  • IPv4-mapped IPv6 addresses (::ffff:10.0.5.7) leave as IPv4 packets, so the drop rule for 10.0.5.7 still applies to them.

Erring safe: combined nmap flags like -sSV don't match the raw-socket list, but nmap then quits by itself ("requires root privileges") and sends nothing; only the message is nmap's rather than codebud's.

Residual risks (decided):

  • the client's DNS decides what a name resolves to;
  • a CDN address shared with other sites is reachable while the step runs;
  • a loopback proxy (Burp) isn't reachable from a fenced step;
  • fence_scanning = false leaves scanning to the command-line guard alone.

1778 tests pass, make distcheck passes, and ruff is clean. Verdict: correct. I'll merge with a merge commit once CI passes on all jobs, macOS included.

@mkhellat

Copy link
Copy Markdown
Owner Author

Merging. The review found no defects. CI is green on all five jobs at ac10eb0, macOS included; the live fence test skips there, as it does on runners without pasta. main has not moved since the branch was cut. A merge commit keeps the two commits as written.

@mkhellat
mkhellat merged commit 7146c65 into main Nov 12, 2026
5 checks passed
@mkhellat
mkhellat deleted the feat/scope-fence branch November 12, 2026 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant